Skip to content

fix(compiler): scope single-definition variable inlining to the declaring block - #413

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/single-definition-inliner-scope
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/single-definition-inliner-scope

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

inlineVariables folds a custom property declared once into every var() that reads it and deletes the declaration, counting declarations with a visitor that cannot see the rule around them, so the fold ignores where the declaration applies. On main:

  1. A declaration reaches rules that cannot inherit it. .parent { --x: 10px } .child { width: var(--x) } compiles .child to width: 10, so an element carrying only .child gets a width Chromium, Firefox and WebKit do not give it (css-variables-1 §2), and a second --x anywhere in the sheet turns the fold off.
  2. A dropped @supports block still declares. Tailwind repeats each @property initial value in an @supports fallback, which the compiler drops (supportsConditionValid) while the visitor still counts the declaration in it, the value that folds: with @property --x { syntax: "*"; inherits: false; initial-value: 10px }, a fallback * { --x: 20px } and .b { width: var(--x) }, main folds 20, where inlineVariables: false, Chromium and Firefox render 10.
  3. The fold ignores inherits: false. :root { --x: 20px } folds 20 into .b although the registration says other elements hold the initial value; here the reference is left to the runtime, which fix(native): honour inherits: false on registered custom properties #412 teaches the registration.
  4. A declaration is removed while something still reads it. .a { --c: 0.5; color: rgb(255 0 0 / var(--c)) } loses --c, because the fold does not reach inside an unresolved colour, and the alpha comes out empty.

Folding also picks the parser a value meets, and three disagree with the runtime:

  • a variable holding 0 renders nothing unfolded, because parseUnparsed read 0 as an absence;
  • an unfolded <ratio> keeps the text 16 / 9 where the property parser gives 16/9;
  • a compiled transparent shadow is kept where the runtime drops it.

Solution

The inliner takes its census from its own walk over the stylesheet and folds a value into a var() only when every element the reference's rule matches holds it:

  • the reference is in the block that declares it, or
  • the declaration reaches every element: *, or :root, :host and html for a property that inherits, outside @media and @container.

An @supports block is decided by one rulesTheCompilerKeeps, which extractRule now uses too, so a kept block is unconditional and a dropped one is emptied before the census. A registered *-syntax property that nothing declares folds to its initial value (css-properties-values-api-1 §3.3). A declaration is removed once no rule reads it, so one another rule reads at runtime stays.

The three parsers now agree with the runtime: 0 and false are values, a <ratio> gets parseAspectRatio's descriptor, and both paths drop a transparent shadow through one isTransparentColor.

Three existing expectations change: transparent shadow is preserved pinned the compile-time path keeping it, and both paths drop it now; scale-0 and scale-50 now fold, so they get the compiler's scaleX and scaleY for the runtime's scale, the same scale; and three variables.test.tsx cases read "green" and "red" for "#008000" and "#f00", since the colour now arrives by runtime inheritance, which keeps a named colour as written, and React Native reads both.

Tests

On main, 45 of the 209 cases in the six files fail; all pass here, and each of 25 mutations, one per rule, fails a case.

The scope cases agree with Chromium 156, Firefox 157 and WebKit 27.2, from * and :root under inherits: false and an element outside the declaring rule to a variable declared initial and a keyframe's --x read by its declaring rule; in the Tailwind-shaped fallback, Chromium and Firefox drop the block as the compiler does, and Playwright's WebKit keeps it.

Verification

Every ci.yml job passes on this branch merged into main (2421ea7): lint, typecheck, yarn test (1472 passed), node --test, both builds with nothing unstaged and commitlint on Ubuntu with Node 22.18.0, and the iOS dev build with my Mac's Xcode 27.1, where ci.yml pins 26.6.

Compiling a Tailwind v4 stylesheet of 1,708 classes takes about 480 ms here as on main (Node 26.12.1, 360 compiles a side): this branch is 1.7% faster, inside the 2.1% two runs of one tree differ by.

No existing issue tracks this; I searched inlineVariables, variable inlining, custom property scope, @property initial-value and @supports.

Device evidence, before and after: #413 (comment)

Open questions

  1. A same-block fold is out of a runtime write's reach. .a { --x: red; color: var(--x) } folds within .a, so vars({ "--x": "blue" }) on the element never reaches color; exclude and inlineVariables: false keep it writable. Every fold shares this, :root's and *'s too, as the compiler cannot know what an app writes at runtime: keep same-block folds, or leave them to the runtime?

Related

References main folded across blocks now reach the runtime and its open defects: a shorthand read through a variable reaches React Native as one list (#518), a transition-* longhand read through one stays unanimated (#497), and on lightningcss 1.31 or later a var() left in the sheet makes compile() throw (#470). #412 makes the runtime honour inherits: false.

This conflicts with #379, #389, #391, #411, #412, #417, #420, #489, #491, #494, #495, #497, #501 and #503; I'll re-cut whichever lands after the others.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The two rows disagree — the subject is red OUTSIDE its scope, because a single declaration was folded there.

FIXED — Both rows read the same: red inside the declaring block, green outside it.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

…ring block

The inliner folded a custom property declared once into every var() in the
stylesheet, whether or not the reference's element can hold the declaration.
It now folds a value only into the block that declares it, or from a
declaration outside @media and @container that reaches every element: `*`,
or `:root`, `:host` and `html` for an inheriting property. An @supports block
counts as extractRule decides it, through one rulesTheCompilerKeeps, so a
dropped block is emptied before the census; a registered `*` property that
nothing declares folds to its initial value; and a declaration is removed once
no rule reads it.

Values the fold no longer normalises reach the token path, which now reads
`0` and `false` as values and gives a <ratio> the descriptor parseAspectRatio
gives it. A compiled box-shadow drops a transparent shadow, as the runtime
does.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant